**Final Project** Student name: Chia-Wei Lee Netid: f00813s (#) Inspirations When I think about "entangled", I want to create a scene where objects are actually entagled by lights. So I choose to create a scene where many objects are covered by the lights going through a window blinds. Where the lights sort of "entangle" with the objects, casting lines of shadow on the objects. I want to see different light effects on golden and wooden objects. For my inspirations, it comes from video games 'Metro Exodus' and 'Dishonored 2', where the environment art gives a lot of antique + wood feeling. Overall, the theme I am going for is gold materials, wood materials, old and antique style.

(#) Feature 1: Mitchell–Netravali Filter (1 pts) I choose to implement Mitchell–Netravali filter because my pc is not very powerful, therefore I want to use a better reconstruction filter to reduce noise at low sample counts.
Reference Mitchell–Netravali Filter
Without Mitchell Mitchell–Netravali Filter_zoomed_in
I call the filter function when sampling color in scene.cpp. I set the kernal range at [-2, 2] (at most 4*4 pixels affected by the filter) and followed PBRT's implementation here:

float MitchellFilter::mitchell1d(float x) const
{   
    // src/filter.cpp
    x = abs(x);

    // Scale x into [-2,2] domain expected by the kernel
    // (support is [-radius, radius])
    const float extent = 2.f;
    if (extent > 0.f)
        x = (x / m_radius.x) * extent;

    const float B = m_B;
    const float C = m_C;

    if (x < 1.f)
    {
        return ((12 - 9 * B - 6 * C) * x * x * x + (-18 + 12 * B + 6 * C) * x * x +
                (6 - 2 * B)) *
               (1.f / 6.f);
    }
    else if (x < 2.f)
    {
        return ((-B - 6 * C) * x * x * x + (6 * B + 30 * C) * x * x + (-12 * B - 48 * C) * x +
                (8 * B + 24 * C)) *
               (1.f / 6.f);
    }
    else
    {
        return 0.f;
    }
}
The system fallback to Box filter when no filter is specified in the json file. (#) Feature 2: Additional Procedural Texture: Mugsgrave (2 pts) Mugsgrave texture The Mugsgrave noise is generated by combining multiple octaves of Perlin noise. This creates natural textures such as marble or wood grain. Apply layers of Perlin noise with increasing frequency and decreasing amplitude.

// src/textures/mugsgrave.cpp
for (int i = 0; i < octaves; ++i)
    {
        float n = perlin_noise(p * freq);        // [-1,1]
        n = 0.5f * (n + 1.0f);                   // [0,1]
        sum += amp * n;

        freq *= lacunarity;
        amp  *= std::pow(lacunarity, -dimension);
    }
There are 2 modes, the default FBM mode simply add all layers together. The ridged mode inverts the noise to create sharp ridges, cracked texture.

        float n = perlin_noise(p * freq); // [-1,1]
        n = offset - std::abs(n);         // invert valleys
        n = std::max(n, 0.0f);
        n *= n;                           // sharpen ridges

        n *= weight;
        sum += n * amp;
(#) Feature 3: Environment Map Emitter (2 pts)
Reference Mitchell–Netravali Filter

I choose to implement Environment Map because I want to render the scene at night time. For my implemenation, I build row and column CDF for sampling the environment map. This times all the pixels luminance with the rows' sinθ, then build column CDF with these weights.

// src/surfaces/environment.cpp
for (int y = 0; y < height; ++y)
{
    std::vector<float> cols(width, 0.f);
    float theta = (y + 0.5f) * float(M_PI) / float(height);
    float sin_theta = std::sin(theta);
    for (int x = 0; x < width; ++x)
    {
        float lum = luminance(img(x, y));
        cols[x] = lum * sin_theta;     // luminance * sinθ
    }
    col_cdfs[y].build(cols);
    row_weights[y] = col_cdfs[y].norm();
}
row_cdf.build(row_weights);
   
Based on the row picked, I sample a random column with the same weight and turn (row, col) to spherical coordinates to get the direction. And compute the solid angle PDF accordingly:

pdf = (row_pdf*col_pdf)*width*height / (2π² sinθ)
The emitter is given an infinte bouding box and always return false on intersection, loaded only into m_emitters and not in BVH. This esstentially makes it an infintly far away light source. (#) Feature 4: Simple Extra BSDF (GGX Microfacet Model ) (2 pts)
Diffuse GGX - VNDF + Smith masking ">

Both 8 samples per pixel images. I want to implement realistic light effects on goldern objects. The effect I tried is GGX mirofacet BSDF. For my implementation, I followed PBRT's method, using Smith masking function, and VNDF sampling.

Vec3f sample_visible_normal(const Vec3f &wi, float ax, float ay, const Vec2f &u)
{   
    // VNDF         src/materials/ggx.cpp
    // 1. Stretch wi
    Vec3f wi_stretched = la::normalize(Vec3f(ax * wi.x, ay * wi.y, wi.z));

    // 2. Build orthonormal basis
    float lensq = wi_stretched.x * wi_stretched.x + wi_stretched.y * wi_stretched.y;
    Vec3f T1 = lensq > 0.f ? Vec3f(-wi_stretched.y, wi_stretched.x, 0.f) / std::sqrt(lensq) : Vec3f(1.f, 0.f, 0.f);
    Vec3f T2 = la::cross(wi_stretched, T1);

    // 3. Sample P22_11
    float r = std::sqrt(u.x);
    float phi = 2.f * M_PI * u.y;
    float t1 = r * std::cos(phi);
    float t2 = r * std::sin(phi);
    float s = 0.5f * (1.f + wi_stretched.z);
    t2 = (1.f - s) * std::sqrt(std::max(0.f, 1.f - t1 * t1)) + s * t2;

    // 4. Reproject
    Vec3f Nh = t1 * T1 + t2 * T2 + std::sqrt(std::max(0.f, 1.f - t1 * t1 - t2 * t2)) * wi_stretched;

    // 5. Unstretch
    Vec3f h = la::normalize(Vec3f(ax * Nh.x, ay * Nh.y, std::max(0.f, Nh.z)));
    return h;
}
VNDF generates microfacet normal more efficiently. VNDF is computed first to generate the direction sample, then PDF() and eval() will be called to compute the GGX PDF and BRDF.

float ggx_D_aniso(const Vec3f &m, float ax, float ay)
{   
    // The D term
    if (m.z <= 0.f)
        return 0.f;
    float tx = m.x / ax;
    float ty = m.y / ay;
    float denom = tx * tx + ty * ty + m.z * m.z;
    denom = denom * denom;
    return 1.f / (M_PI * ax * ay * denom);
}
The D term defines the distribution of microfacet normals. In my implementation I did anisotropic materail which use 2 roughness variables and gives better flexibility.

float ggx_G_aniso(const Vec3f &wi, const Vec3f &wo, float ax, float ay)
{
    float lambda_i = ggx_lambda_aniso(wi, ax, ay);
    float lambda_o = ggx_lambda_aniso(wo, ax, ay);
    float denom = 1.f + lambda_i + lambda_o;
    return denom > 0.f ? 1.f / denom : 0.f;
}
The geometry term computes self-shadowing and masking.

    Color3f f = F * (D * G) / std::max(4.f * wi.z * wo.z, 1e-7f);
    return f * wo.z; // return f * cos(theta_o)
Combine D, G and Fresnel term we get the GGX BRDF value. (#) Feature 5: Homogeneous Participating Media (4 pts) Comparison between renderings with and without homogeneous participating media (fog). Both images are rendered with Mitchell–Netravali filter at 128 spp.
Reference Homogeneous Participating Media

I want to give the image a 'dusty' feelng in the air, therefore I implemented a volumetric path tracer with participating media, free-flight sampling, phase/BSDF sampling, and MIS-based NEE (supports multiple scattering). I loaded the medium parameters from json file: albedo(σs/σt)、total(σt)、real fraction and phase function, and derive σa, σs, σn form it. sample_free_flight() samples a distance based on exponential distribution, and use it to find potential intersection point at range [mint, maxt].

bool sample_free_flight(const Ray3f &ray, int channel, Sampler &sampler, HitRecord &hit, Color3f &f,
                            Color3f &p) const override
    // src/media/homogeneous.cpp
    {
        // sample a free-flight distance with majorant
        float dist = -std::log(1.f - sampler.next1f()) / m_total[channel];

        // the sampled distance is along the ray, so the new potential hit point is
        hit.t = ray.mint + dist;

        if (hit.t < ray.maxt)
        {
            // inside medium, so this is a potential collision
            hit.p = ray(hit.t);

            // what's the density at this point?
            Color3f real_ext = m_total * m_real_fraction;

            // update throughput and pdf for real scattering event
            f *= exp(-m_total * dist) * real_ext;
            p *= exp(-m_total * dist) * m_total;
            // f / p = the possibility of a real scattering event (computed in vol_path_tracer.cpp)
            
            return true;
        }
        else
        {
            ...
            return false;
        }
    }
If distance is within [mint, maxt], there is a potential medium interaction and the function returns true. Else if distance is out of range: 1. adjust hit.t to maxt (rays exits medium), 2. times real fraction with the transmiitance in this section, 3. return false. In vol_path_tracer.cpp, I integrated the MIS code with the medium sampling. Everytime the sample_free_glight() from homogeneous.cpp returns true, it signals a medium interaction. The integrator then samples a new direction by: 1. timeing throughput with real fraction. 2. then conduct a new NEE with phase function.

if (medium)
// src/integrators/vol_path_tracer.cpp
            {
                Color3f f(1.f), p(1.f);
                int     channel = std::min(int(sampler->next1f() * 3), 2);
                if (medium->sample_free_flight(Ray3f(ray.o, ray.d, ray.mint, found_surface ? surf_hit.t : ray.maxt),
                                               channel, *sampler, med_hit, f, p))
                {
                    if (la::maxelem(p) > 0.f)
                        beta *= f / la::maxelem(p);
                    else
                        break;
                    scattered_in_medium = true;
                }
                else if (la::maxelem(p) > 0.f)
                    beta *= f / la::maxelem(p);
                else
                    break;
            }
(##) Scatter within medias: 1. If there are emitters in the media, sample from the medium point to the light source. Times total transmittance and do MIS with phase function + light source PDF. - Then I sample new direction based on phase function. (If there is not emitter in the medium, sample directly from phase function.)

// Sample scattered direction in medium. 
//src/integrators/vol_path_tracer.cpp
   if (scattered_in_medium)
            {
                specular_bounce = false;
                // NEE for direct lighting in medium
                if (scene->m_emitters)
                {
                    EmitterRecord er(ray, med_hit.p);
                    Color3f Le_over_pdf = scene->m_emitters->sample(er, sampler->next2f());
                    if (er.pdf > 0.f && isfinite(er.pdf))
                    {
                        Ray3f   shadow_ray(med_hit.p, er.wi, Ray3f::epsilon, er.hit.t - Ray3f::epsilon);
                        shadow_ray.medium = medium;
                        HitRecord occ;
                        bool visible = !scene->intersect(shadow_ray, occ) || occ.t + Ray3f::epsilon >= er.hit.t;
                        if (visible)
                        {
                            Color3f tr = medium->total_transmittance(shadow_ray, *sampler);
                            if (la::maxelem(tr) > 0.f)
                            {
                                float phase_pdf  = medium->phase_function->pdf(ray, er.wi, med_hit);
                                float mis_weight = er.pdf / (er.pdf + phase_pdf);
                                L += beta * medium->phase_function->eval(ray, er.wi, med_hit) * tr * Le_over_pdf * mis_weight;
                            }
                        }
                    }
                }
            }
2. If it didnt't scatter and didn't hit any surface: Use light source PDF + Phase function for MIS if the last intersection is not specular, if it is specular then just add background color. 3. If it didn't scatter but hit a surface: - For emitter surface: check if the previous bounce is specular or initialized ray, add them directly into L. - For non-emitter surface: do MIS sample on new direction based on BSDF + light source PDF.

    // src/integrators/vol_path_tracer.cpp
    if (surf_hit.mat->is_emissive()) {
                    if (specular_bounce) {
                        L += beta * surf_hit.mat->emitted(ray, surf_hit);
                    } else {
                        float light_pdf = scene->m_emitters->pdf(prev_hit.p, ray.d);
                        float bsdf_pdf =
                            prev_medium ? prev_medium->phase_function->pdf(prev_ray, ray.d, prev_hit) :
                                          (prev_hit.mat ? prev_hit.mat->pdf(prev_ray, ray.d, prev_hit) : 0.f);
                        float mis_weight = bsdf_pdf / (bsdf_pdf + light_pdf);
                        L += beta * surf_hit.mat->emitted(ray, surf_hit) * mis_weight;
                    }
                }
(#) Feature 6: Rough Conductor Material (2 pts)
Diffuse GGX - VNDF + Smith masking "> Rough conductor - use complex Fresnel

To create metal texture, I developed rough conductor material using GGX. The rough conductor material uses full Fresnel formula instead of Schlick Fresnel like GGX did.

Color3f fresnel_conductor(float cos_theta_i, const Color3f &eta, const Color3f &k)
{
    cos_theta_i = std::abs(cos_theta_i);
    float cos2 = cos_theta_i * cos_theta_i;
    float sin2 = std::max(0.f, 1.f - cos2);

    Color3f eta2 = eta * eta;
    Color3f k2 = k * k;

    Color3f t0 = eta2 - k2 - Color3f(sin2);
    Color3f a2b2 = Color3f{std::sqrt(t0.x * t0.x + 4.f * eta2.x * k2.x),
                           std::sqrt(t0.y * t0.y + 4.f * eta2.y * k2.y),
                           std::sqrt(t0.z * t0.z + 4.f * eta2.z * k2.z)};

    Color3f t1 = a2b2 + Color3f(cos2);
    Color3f a = Color3f{std::sqrt(0.5f * (a2b2.x + t0.x)),
                        std::sqrt(0.5f * (a2b2.y + t0.y)),
                        std::sqrt(0.5f * (a2b2.z + t0.z))};
    Color3f t2 = 2.f * cos_theta_i * a;
    Color3f Rs = (t1 - t2) / (t1 + t2);

    Color3f t3 = Color3f(cos2) * a2b2 + Color3f(sin2 * sin2);
    Color3f t4 = t2 * Color3f(sin2);
    Color3f Rp = Rs * ((t3 - t4) / (t3 + t4));

    Color3f F = 0.5f * (Rp + Rs);
    return clamp01(F);
}
(#) Feature 7~9: Three additional lighting sources (1*3 pts) I also added 3 light sources to see how they their effect would be if going through window blinds. I ultimately decided to use sun (directional) light.
sun light Point light "> Spot light
(#) Final rendering My Final Render I didn't really get the desired "window blinds shadow" result I wanted to. I think the main reason is thet it is very difficult to sample lights properly in a dark room where the only light source are from the window. This image without texture applied illustrates the effect I want: Point light A potential solution to this would be to take a lot more samples (>1024 spp). Or I will have to try a more efficient light sampling stratagies such as bi-directional path tracing. (#) Encountered Problems: The hardest part in my opinion is setting up blender scene. It is incredibly difficult to adjust these cameras and angles, and dealing with missing objects and textures is very time consuming. The Homogeneous meida is also very challanging to implement properly. (#) External Libraries (project/report) For this task, I mostly used the libraries provided in the DARTS repo. (#) Reference Inspiration image 1: https://andrzejdybowski.com/portfolio/metro-exodus/# Inspiration image 2: https://80.lv/articles/environment-storytelling-in-dishonored-2